// This file is part of OpenTSDB.
// Copyright (C) 2010-2012  The OpenTSDB Authors.
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 2.1 of the License, or (at your
// option) any later version.  This program is distributed in the hope that it
// will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser
// General Public License for more details.  You should have received a copy
// of the GNU Lesser General Public License along with this program.  If not,
// see <http://www.gnu.org/licenses/>.
package net.opentsdb.tsd.client;

/*
 * DISCLAIMER
 * This my first GWT code ever, so it's most likely horribly wrong as I've had
 * virtually no exposure to the technology except through the tutorial. --tsuna
 */

import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.dom.client.Style;
import com.google.gwt.event.dom.client.*;
import com.google.gwt.event.logical.shared.BeforeSelectionEvent;
import com.google.gwt.event.logical.shared.BeforeSelectionHandler;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.event.shared.EventHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.http.client.*;
import com.google.gwt.i18n.client.DateTimeFormat;
import com.google.gwt.json.client.*;
import com.google.gwt.user.client.*;
import com.google.gwt.user.client.ui.*;

import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;

/**
 * Root class for the 'query UI'.
 * Manages the entire UI, forms to query the TSDB and other misc panels.
 */
public class QueryUi implements EntryPoint, HistoryListener {
    // Some URLs we use to fetch data from the TSD.
    private static final String AGGREGATORS_URL = "/aggregators";
    private static final String LOGS_URL = "/logs?json";
    private static final String STATS_URL = "/stats?json";
    private static final String VERSION_URL = "/version?json";

    private static final DateTimeFormat FULLDATE =
            DateTimeFormat.getFormat("yyyy/MM/dd-HH:mm:ss");

    private final Label current_error = new Label();

    private final DateTimeBox start_datebox = new DateTimeBox();
    private final DateTimeBox end_datebox = new DateTimeBox();
    private final CheckBox autoreload = new CheckBox("Autoreload");
    private final ValidatedTextBox autoreoload_interval = new ValidatedTextBox();
    private Timer autoreoload_timer;

    private final ValidatedTextBox yrange = new ValidatedTextBox();
    private final ValidatedTextBox y2range = new ValidatedTextBox();
    private final CheckBox ylog = new CheckBox();
    private final CheckBox y2log = new CheckBox();
    private final TextBox ylabel = new TextBox();
    private final TextBox y2label = new TextBox();
    private final ValidatedTextBox yformat = new ValidatedTextBox();
    private final ValidatedTextBox y2format = new ValidatedTextBox();
    private final ValidatedTextBox wxh = new ValidatedTextBox();

    private String keypos = "";  // Position of the key on the graph.
    private final CheckBox horizontalkey = new CheckBox("Horizontal layout");
    private final CheckBox keybox = new CheckBox("Box");
    private final CheckBox nokey = new CheckBox("No key (overrides others)");

    // Styling options.
    private final CheckBox smooth = new CheckBox();

    /**
     * Handles every change to the query form and gets a new graph.
     * Whenever the user changes one of the parameters of the graph, we want
     * to automatically get a new graph.
     */
    private final EventsHandler refreshgraph = new EventsHandler() {
        protected <H extends EventHandler> void onEvent(final DomEvent<H> event) {
            refreshGraph();
        }
    };

    final MetricForm.MetricChangeHandler metric_change_handler =
            new MetricForm.MetricChangeHandler() {
                public void onMetricChange(final MetricForm metric) {
                    final int index = metrics.getWidgetIndex(metric);
                    metrics.getTabBar().setTabText(index, getTabTitle(metric));
                }

                private String getTabTitle(final MetricForm metric) {
                    final String metrictext = metric.getMetric();
                    final int last_period = metrictext.lastIndexOf('.');
                    if (last_period < 0) {
                        return metrictext;
                    }
                    return metrictext.substring(last_period + 1);
                }
            };

    final EventsHandler updatey2range = new EventsHandler() {
        protected <H extends EventHandler> void onEvent(final DomEvent<H> event) {
            for (final Widget metric : metrics) {
                if (!(metric instanceof MetricForm)) {
                    continue;
                }
                if (((MetricForm) metric).x1y2().getValue()) {
                    y2range.setEnabled(true);
                    y2log.setEnabled(true);
                    y2label.setEnabled(true);
                    y2format.setEnabled(true);
                    return;
                }
            }
            y2range.setEnabled(false);
            y2log.setEnabled(false);
            y2label.setEnabled(false);
            y2format.setEnabled(false);
        }
    };

    /**
     * List of known aggregation functions.  Fetched once from the server.
     */
    private final ArrayList<String> aggregators = new ArrayList<String>();

    private final DecoratedTabPanel metrics = new DecoratedTabPanel();

    /**
     * Panel to place generated graphs and a box for zoom highlighting.
     */
    private final AbsolutePanel graphbox = new AbsolutePanel();
    private final Image graph = new Image();
    private final ZoomBox zoom_box = new ZoomBox();
    private final Label graphstatus = new Label();
    /**
     * Remember the last URI requested to avoid requesting twice the same.
     */
    private String lastgraphuri;

    /**
     * We only send one request at a time, how many have we not sent yet?.
     * Note that we don't buffer pending requests.  When there are multiple
     * ones pending, we will only execute the last one and discard the other
     * intermediate ones, since the user is no longer interested in them.
     */
    private int pending_requests = 0;
    /**
     * How many graph requests we make.
     */
    private int nrequests = 0;

    // Other misc panels.
    private final FlexTable logs = new FlexTable();
    private final FlexTable stats_table = new FlexTable();
    private final HTML build_data = new HTML("Loading...");

    /**
     * This is the entry point method.
     */
    public void onModuleLoad() {
        asyncGetJson(AGGREGATORS_URL, new GotJsonCallback() {
            public void got(final JSONValue json) {
                // Do we need more manual type checking?  Not sure what will happen
                // in the browser if something other than an array is returned.
                final JSONArray aggs = json.isArray();
                for (int i = 0; i < aggs.size(); i++) {
                    aggregators.add(aggs.get(i).isString().stringValue());
                }
                ((MetricForm) metrics.getWidget(0)).setAggregators(aggregators);
                refreshFromQueryString();
                refreshGraph();
            }
        });

        // All UI elements need to regenerate the graph when changed.
        {
            final ValueChangeHandler<Date> vch = new ValueChangeHandler<Date>() {
                public void onValueChange(final ValueChangeEvent<Date> event) {
                    refreshGraph();
                }
            };
            TextBox tb = start_datebox.getTextBox();
            tb.addBlurHandler(refreshgraph);
            tb.addKeyPressHandler(refreshgraph);
            start_datebox.addValueChangeHandler(vch);
            tb = end_datebox.getTextBox();
            tb.addBlurHandler(refreshgraph);
            tb.addKeyPressHandler(refreshgraph);
            end_datebox.addValueChangeHandler(vch);
        }
        autoreoload_interval.addBlurHandler(refreshgraph);
        autoreoload_interval.addKeyPressHandler(refreshgraph);
        yrange.addBlurHandler(refreshgraph);
        yrange.addKeyPressHandler(refreshgraph);
        y2range.addBlurHandler(refreshgraph);
        y2range.addKeyPressHandler(refreshgraph);
        ylog.addClickHandler(new AdjustYRangeCheckOnClick(ylog, yrange));
        y2log.addClickHandler(new AdjustYRangeCheckOnClick(y2log, y2range));
        ylog.addClickHandler(refreshgraph);
        y2log.addClickHandler(refreshgraph);
        ylabel.addBlurHandler(refreshgraph);
        ylabel.addKeyPressHandler(refreshgraph);
        y2label.addBlurHandler(refreshgraph);
        y2label.addKeyPressHandler(refreshgraph);
        yformat.addBlurHandler(refreshgraph);
        yformat.addKeyPressHandler(refreshgraph);
        y2format.addBlurHandler(refreshgraph);
        y2format.addKeyPressHandler(refreshgraph);
        wxh.addBlurHandler(refreshgraph);
        wxh.addKeyPressHandler(refreshgraph);
        horizontalkey.addClickHandler(refreshgraph);
        keybox.addClickHandler(refreshgraph);
        nokey.addClickHandler(refreshgraph);
        smooth.addClickHandler(refreshgraph);

        yrange.setValidationRegexp("^("                            // Nothing or
                + "|\\[([-+.0-9eE]+|\\*)?"      // "[start
                + ":([-+.0-9eE]+|\\*)?\\])$");  //   :end]"
        yrange.setVisibleLength(5);
        yrange.setMaxLength(44);  // MAX=2^26=20 chars: "[-$MAX:$MAX]"
        yrange.setText("[0:]");

        y2range.setValidationRegexp("^("                            // Nothing or
                + "|\\[([-+.0-9eE]+|\\*)?"      // "[start
                + ":([-+.0-9eE]+|\\*)?\\])$");  //   :end]"
        y2range.setVisibleLength(5);
        y2range.setMaxLength(44);  // MAX=2^26=20 chars: "[-$MAX:$MAX]"
        y2range.setText("[0:]");
        y2range.setEnabled(false);
        y2log.setEnabled(false);

        ylabel.setVisibleLength(10);
        ylabel.setMaxLength(50);  // Arbitrary limit.
        y2label.setVisibleLength(10);
        y2label.setMaxLength(50);  // Arbitrary limit.
        y2label.setEnabled(false);

        yformat.setValidationRegexp("^(|.*%..*)$");  // Nothing or at least one %?
        yformat.setVisibleLength(10);
        yformat.setMaxLength(16);  // Arbitrary limit.
        y2format.setValidationRegexp("^(|.*%..*)$");  // Nothing or at least one %?
        y2format.setVisibleLength(10);
        y2format.setMaxLength(16);  // Arbitrary limit.
        y2format.setEnabled(false);

        wxh.setValidationRegexp("^[1-9][0-9]{2,}x[1-9][0-9]{2,}$");  // 100x100
        wxh.setVisibleLength(9);
        wxh.setMaxLength(11);  // 99999x99999
        wxh.setText((Window.getClientWidth() - 20) + "x"
                + (Window.getClientHeight() * 4 / 5));

        final FlexTable table = new FlexTable();
        table.setText(0, 0, "From");
        {
            final HorizontalPanel hbox = new HorizontalPanel();
            hbox.add(new InlineLabel("To"));
            final Anchor now = new Anchor("(now)");
            now.addClickHandler(new ClickHandler() {
                public void onClick(final ClickEvent event) {
                    end_datebox.setValue(new Date());
                    refreshGraph();
                }
            });
            hbox.add(now);
            hbox.add(autoreload);
            hbox.setWidth("100%");
            table.setWidget(0, 1, hbox);
        }
        autoreload.addValueChangeHandler(new ValueChangeHandler<Boolean>() {
            @Override
            public void onValueChange(final ValueChangeEvent<Boolean> event) {
                if (autoreload.getValue()) {
                    final HorizontalPanel hbox = new HorizontalPanel();
                    hbox.setWidth("100%");
                    hbox.add(new InlineLabel("Every:"));
                    hbox.add(autoreoload_interval);
                    hbox.add(new InlineLabel("seconds"));
                    table.setWidget(1, 1, hbox);
                    if (autoreoload_interval.getValue().isEmpty()) {
                        autoreoload_interval.setValue("15");
                    }
                    autoreoload_interval.setFocus(true);
                    lastgraphuri = "";  // Force refreshGraph.
                    refreshGraph();     // Trigger the 1st auto-reload
                } else {
                    table.setWidget(1, 1, end_datebox);
                }
            }
        });
        autoreoload_interval.setValidationRegexp("^([5-9]|[1-9][0-9]+)$");  // >=5s
        autoreoload_interval.setMaxLength(4);
        autoreoload_interval.setVisibleLength(8);

        table.setWidget(1, 0, start_datebox);
        table.setWidget(1, 1, end_datebox);
        {
            final HorizontalPanel hbox = new HorizontalPanel();
            hbox.add(new InlineLabel("WxH:"));
            hbox.add(wxh);
            table.setWidget(0, 3, hbox);
        }
        {
            addMetricForm("metric 1", 0);
            metrics.selectTab(0);
            metrics.add(new InlineLabel("Loading..."), "+");
            metrics.addBeforeSelectionHandler(new BeforeSelectionHandler<Integer>() {
                public void onBeforeSelection(final BeforeSelectionEvent<Integer> event) {
                    final int item = event.getItem();
                    final int nitems = metrics.getWidgetCount();
                    if (item == nitems - 1) {  // Last item: the "+" was clicked.
                        event.cancel();
                        final MetricForm metric = addMetricForm("metric " + nitems, item);
                        metrics.selectTab(item);
                        metric.setFocus(true);
                    }
                }
            });
            table.setWidget(2, 0, metrics);
        }
        table.getFlexCellFormatter().setColSpan(2, 0, 2);
        table.getFlexCellFormatter().setRowSpan(1, 3, 2);
        final DecoratedTabPanel optpanel = new DecoratedTabPanel();
        optpanel.add(makeAxesPanel(), "Axes");
        optpanel.add(makeKeyPanel(), "Key");
        optpanel.add(makeStylePanel(), "Style");
        optpanel.selectTab(0);
        table.setWidget(1, 3, optpanel);

        final DecoratorPanel decorator = new DecoratorPanel();
        decorator.setWidget(table);
        final VerticalPanel graphpanel = new VerticalPanel();
        graphpanel.add(decorator);
        {
            final VerticalPanel graphvbox = new VerticalPanel();
            graphvbox.add(graphstatus);

            graph.setVisible(false);

            // Put the graph image element and the zoombox elements inside the absolute panel
            graphbox.add(graph, 0, 0);
            zoom_box.setVisible(false);
            graphbox.add(zoom_box, 0, 0);

            graphvbox.add(graphbox);
            graph.addErrorHandler(new ErrorHandler() {
                public void onError(final ErrorEvent event) {
                    graphstatus.setText("Oops, failed to load the graph.");
                }
            });
            graph.addLoadHandler(new LoadHandler() {
                public void onLoad(final LoadEvent event) {
                    graphbox.setWidth(graph.getWidth() + "px");
                    graphbox.setHeight(graph.getHeight() + "px");
                }
            });

            graphpanel.add(graphvbox);
        }
        final DecoratedTabPanel mainpanel = new DecoratedTabPanel();
        mainpanel.setWidth("100%");
        mainpanel.add(graphpanel, "Graph");
        mainpanel.add(stats_table, "Stats");
        mainpanel.add(logs, "Logs");
        mainpanel.add(build_data, "Version");
        mainpanel.selectTab(0);
        mainpanel.addBeforeSelectionHandler(new BeforeSelectionHandler<Integer>() {
            public void onBeforeSelection(final BeforeSelectionEvent<Integer> event) {
                clearError();
                final int item = event.getItem();
                switch (item) {
                    case 1:
                        refreshStats();
                        return;
                    case 2:
                        refreshLogs();
                        return;
                    case 3:
                        refreshVersion();
                        return;
                }
            }
        });
        final VerticalPanel root = new VerticalPanel();
        root.setWidth("100%");
        root.add(current_error);
        current_error.setVisible(false);
        current_error.addStyleName("dateBoxFormatError");
        root.add(mainpanel);
        RootPanel.get("queryuimain").add(root);
        // Must be done at the end, once all the widgets are attached.
        ensureSameWidgetSize(optpanel);

        History.addHistoryListener(this);
    }

    @Override
    public void onHistoryChanged(String historyToken) {
        refreshFromQueryString();
        refreshGraph();
    }

    /**
     * Additional styling options.
     */
    private Grid makeStylePanel() {
        final Grid grid = new Grid(5, 3);
        grid.setText(0, 1, "Smooth");
        grid.setWidget(0, 2, smooth);
        return grid;
    }

    /**
     * Builds the panel containing customizations for the axes of the graph.
     */
    private Grid makeAxesPanel() {
        final Grid grid = new Grid(5, 3);
        grid.setText(0, 1, "Y");
        grid.setText(0, 2, "Y2");
        setTextAlignCenter(grid.getRowFormatter().getElement(0));
        grid.setText(1, 0, "Label");
        grid.setWidget(1, 1, ylabel);
        grid.setWidget(1, 2, y2label);
        grid.setText(2, 0, "Format");
        grid.setWidget(2, 1, yformat);
        grid.setWidget(2, 2, y2format);
        grid.setText(3, 0, "Range");
        grid.setWidget(3, 1, yrange);
        grid.setWidget(3, 2, y2range);
        grid.setText(4, 0, "Log scale");
        grid.setWidget(4, 1, ylog);
        grid.setWidget(4, 2, y2log);
        setTextAlignCenter(grid.getCellFormatter().getElement(4, 1));
        setTextAlignCenter(grid.getCellFormatter().getElement(4, 2));
        return grid;
    }

    private MetricForm addMetricForm(final String label, final int item) {
        final MetricForm metric = new MetricForm(refreshgraph);
        metric.x1y2().addClickHandler(updatey2range);
        metric.setMetricChangeHandler(metric_change_handler);
        metric.setAggregators(aggregators);
        metrics.insert(metric, label, item);
        return metric;
    }

    private final HashMap<String, RadioButton> keypos_map =
            new HashMap<String, RadioButton>(17);

    /**
     * Small helper to build a radio button used to change the position of the
     * key of the graph.
     */
    private RadioButton addKeyRadioButton(final Grid grid,
                                          final int row, final int col,
                                          final String pos) {
        final RadioButton rb = new RadioButton("keypos");
        rb.addClickHandler(new ClickHandler() {
            public void onClick(final ClickEvent event) {
                keypos = pos;
            }
        });
        rb.addClickHandler(refreshgraph);
        grid.setWidget(row, col, rb);
        keypos_map.put(pos, rb);
        return rb;
    }

    /**
     * Builds the panel containing the customizations for the key of the graph.
     */
    private Widget makeKeyPanel() {
        final Grid grid = new Grid(5, 5);
        addKeyRadioButton(grid, 0, 0, "out left top");
        addKeyRadioButton(grid, 0, 2, "out center top");
        addKeyRadioButton(grid, 0, 4, "out right top");
        addKeyRadioButton(grid, 1, 1, "top left");
        addKeyRadioButton(grid, 1, 2, "top center");
        addKeyRadioButton(grid, 1, 3, "top right").setValue(true);
        addKeyRadioButton(grid, 2, 0, "out center left");
        addKeyRadioButton(grid, 2, 1, "center left");
        addKeyRadioButton(grid, 2, 2, "center");
        addKeyRadioButton(grid, 2, 3, "center right");
        addKeyRadioButton(grid, 2, 4, "out center right");
        addKeyRadioButton(grid, 3, 1, "bottom left");
        addKeyRadioButton(grid, 3, 2, "bottom center");
        addKeyRadioButton(grid, 3, 3, "bottom right");
        addKeyRadioButton(grid, 4, 0, "out bottom left");
        addKeyRadioButton(grid, 4, 2, "out bottom center");
        addKeyRadioButton(grid, 4, 4, "out bottom right");
        final Grid.CellFormatter cf = grid.getCellFormatter();
        cf.getElement(1, 1).getStyle().setProperty("borderLeft", "1px solid #000");
        cf.getElement(1, 1).getStyle().setProperty("borderTop", "1px solid #000");
        cf.getElement(1, 2).getStyle().setProperty("borderTop", "1px solid #000");
        cf.getElement(1, 3).getStyle().setProperty("borderTop", "1px solid #000");
        cf.getElement(1, 3).getStyle().setProperty("borderRight", "1px solid #000");
        cf.getElement(2, 1).getStyle().setProperty("borderLeft", "1px solid #000");
        cf.getElement(2, 3).getStyle().setProperty("borderRight", "1px solid #000");
        cf.getElement(3, 1).getStyle().setProperty("borderLeft", "1px solid #000");
        cf.getElement(3, 1).getStyle().setProperty("borderBottom", "1px solid #000");
        cf.getElement(3, 2).getStyle().setProperty("borderBottom", "1px solid #000");
        cf.getElement(3, 3).getStyle().setProperty("borderBottom", "1px solid #000");
        cf.getElement(3, 3).getStyle().setProperty("borderRight", "1px solid #000");
        final VerticalPanel vbox = new VerticalPanel();
        vbox.add(new InlineLabel("Key location:"));
        vbox.add(grid);
        vbox.add(horizontalkey);
        keybox.setValue(true);
        vbox.add(keybox);
        vbox.add(nokey);
        return vbox;
    }

    private void refreshStats() {
        asyncGetJson(STATS_URL, new GotJsonCallback() {
            public void got(final JSONValue json) {
                final JSONArray stats = json.isArray();
                final int nstats = stats.size();
                for (int i = 0; i < nstats; i++) {
                    final String stat = stats.get(i).isString().stringValue();
                    String part = stat.substring(0, stat.indexOf(' '));
                    stats_table.setText(i, 0, part);  // metric
                    int pos = part.length() + 1;
                    part = stat.substring(pos, stat.indexOf(' ', pos));
                    stats_table.setText(i, 1, part);  // timestamp
                    pos += part.length() + 1;
                    part = stat.substring(pos, stat.indexOf(' ', pos));
                    stats_table.setText(i, 2, part);  // value
                    pos += part.length() + 1;
                    stats_table.setText(i, 3, stat.substring(pos));  // tags
                }
            }
        });
    }

    private void refreshVersion() {
        asyncGetJson(VERSION_URL, new GotJsonCallback() {
            public void got(final JSONValue json) {
                final JSONObject bd = json.isObject();
                final JSONString shortrev = bd.get("short_revision").isString();
                final JSONString status = bd.get("repo_status").isString();
                final JSONNumber stamp = bd.get("timestamp").isNumber();
                final JSONString user = bd.get("user").isString();
                final JSONString host = bd.get("host").isString();
                final JSONString repo = bd.get("repo").isString();
                build_data.setHTML(
                        "OpenTSDB built from revision " + shortrev.stringValue()
                                + " in a " + status.stringValue() + " state<br/>"
                                + "Built on " + new Date((long) (stamp.doubleValue() * 1000))
                                + " by " + user.stringValue() + '@' + host.stringValue()
                                + ':' + repo.stringValue()
                );
            }
        });
    }

    private void refreshLogs() {
        asyncGetJson(LOGS_URL, new GotJsonCallback() {
            public void got(final JSONValue json) {
                final JSONArray logmsgs = json.isArray();
                final int nmsgs = logmsgs.size();
                final FlexTable.FlexCellFormatter fcf = logs.getFlexCellFormatter();
                final FlexTable.RowFormatter rf = logs.getRowFormatter();
                for (int i = 0; i < nmsgs; i++) {
                    final String msg = logmsgs.get(i).isString().stringValue();
                    String part = msg.substring(0, msg.indexOf('\t'));
                    logs.setText(i * 2, 0,
                            new Date(Integer.valueOf(part) * 1000L).toString());
                    logs.setText(i * 2 + 1, 0, "");  // So we can change the style ahead.
                    int pos = part.length() + 1;
                    part = msg.substring(pos, msg.indexOf('\t', pos));
                    if ("WARN".equals(part)) {
                        rf.getElement(i * 2).getStyle().setBackgroundColor("#FCC");
                        rf.getElement(i * 2 + 1).getStyle().setBackgroundColor("#FCC");
                    } else if ("ERROR".equals(part)) {
                        rf.getElement(i * 2).getStyle().setBackgroundColor("#F99");
                        rf.getElement(i * 2 + 1).getStyle().setBackgroundColor("#F99");
                    } else {
                        rf.getElement(i * 2).getStyle().clearBackgroundColor();
                        rf.getElement(i * 2 + 1).getStyle().clearBackgroundColor();
                        if ((i % 2) == 0) {
                            rf.addStyleName(i * 2, "subg");
                            rf.addStyleName(i * 2 + 1, "subg");
                        }
                    }
                    pos += part.length() + 1;
                    logs.setText(i * 2, 1, part); // level
                    part = msg.substring(pos, msg.indexOf('\t', pos));
                    pos += part.length() + 1;
                    logs.setText(i * 2, 2, part); // thread
                    part = msg.substring(pos, msg.indexOf('\t', pos));
                    pos += part.length() + 1;
                    if (part.startsWith("net.opentsdb.")) {
                        part = part.substring(13);
                    } else if (part.startsWith("org.hbase.")) {
                        part = part.substring(10);
                    }
                    logs.setText(i * 2, 3, part); // logger
                    logs.setText(i * 2 + 1, 0, msg.substring(pos)); // message
                    fcf.setColSpan(i * 2 + 1, 0, 4);
                    rf.addStyleName(i * 2, "fwf");
                    rf.addStyleName(i * 2 + 1, "fwf");
                }
            }
        });
    }

    private void addLabels(final StringBuilder url) {
        final String ylabel = this.ylabel.getText();
        if (!ylabel.isEmpty()) {
            url.append("&ylabel=").append(ylabel);
        }
        if (y2label.isEnabled()) {
            final String y2label = this.y2label.getText();
            if (!y2label.isEmpty()) {
                url.append("&y2label=").append(y2label);
            }
        }
    }

    private void addFormats(final StringBuilder url) {
        final String yformat = this.yformat.getText();
        if (!yformat.isEmpty()) {
            url.append("&yformat=").append(yformat);
        }
        if (y2format.isEnabled()) {
            final String y2format = this.y2format.getText();
            if (!y2format.isEmpty()) {
                url.append("&y2format=").append(y2format);
            }
        }
    }

    private void addYRanges(final StringBuilder url) {
        final String yrange = this.yrange.getText();
        if (!yrange.isEmpty()) {
            url.append("&yrange=").append(yrange);
        }
        if (y2range.isEnabled()) {
            final String y2range = this.y2range.getText();
            if (!y2range.isEmpty()) {
                url.append("&y2range=").append(y2range);
            }
        }
    }

    private void addLogscales(final StringBuilder url) {
        if (ylog.getValue()) {
            url.append("&ylog");
        }
        if (y2log.isEnabled() && y2log.getValue()) {
            url.append("&y2log");
        }
    }

    /**
     * Maybe sets the text of a {@link TextBox} from a query string parameter.
     *
     * @param qs  A parsed query string.
     * @param key Name of the query string parameter.
     *            If this parameter wasn't passed, the {@link TextBox} will be emptied.
     * @param tb  The {@link TextBox} to change.
     */
    private static void maybeSetTextbox(final QueryString qs,
                                        final String key,
                                        final TextBox tb) {
        final ArrayList<String> values = qs.get(key);
        if (values == null) {
            tb.setText("");
            return;
        }
        tb.setText(values.get(0));
    }

    /**
     * Sets the text of a {@link TextBox} from a query string parameter.
     *
     * @param qs  A parsed query string.
     * @param key Name of the query string parameter.
     * @param tb  The {@link TextBox} to change.
     */
    private static void setTextbox(final QueryString qs,
                                   final String key,
                                   final TextBox tb) {
        final ArrayList<String> values = qs.get(key);
        if (values != null) {
            tb.setText(values.get(0));
        }
    }

    private static QueryString getQueryString(final String qs) {
        return qs.isEmpty() ? new QueryString() : QueryString.decode(qs);
    }

    private void refreshFromQueryString() {
        final QueryString qs = getQueryString(History.getToken());

        maybeSetTextbox(qs, "start", start_datebox.getTextBox());
        maybeSetTextbox(qs, "end", end_datebox.getTextBox());
        setTextbox(qs, "wxh", wxh);
        autoreload.setValue(qs.containsKey("autoreload"), true);
        maybeSetTextbox(qs, "autoreload", autoreoload_interval);

        final ArrayList<String> newmetrics = qs.get("m");
        if (newmetrics == null) {  // Clear all metric forms.
            final int toremove = metrics.getWidgetCount() - 1;
            addMetricForm("metric 1", 0);
            metrics.selectTab(0);
            for (int i = 0; i < toremove; i++) {
                metrics.remove(1);
            }
            return;
        }
        final int n = newmetrics.size();  // We want this many metrics.
        ArrayList<String> options = qs.get("o");
        if (options == null) {
            options = new ArrayList<String>(n);
        }
        for (int i = options.size(); i < n; i++) {  // Make both arrays equal size.
            options.add("");  // Add missing o's.
        }

        for (int i = 0; i < newmetrics.size(); ++i) {
            if (i == metrics.getWidgetCount() - 1) {
                addMetricForm("", i);
            }

            final MetricForm metric = (MetricForm) metrics.getWidget(i);
            metric.updateFromQueryString(newmetrics.get(i), options.get(i));
        }
        // Remove extra metric forms.
        final int m = metrics.getWidgetCount() - 1; // We have this many metrics.
        int showing = metrics.getTabBar().getSelectedTab();  // Currently selected.
        for (int i = m - 1; i >= n; i--) {
            if (showing == i) {  // If we're about to remove the currently selected,
                metrics.selectTab(--showing);  // fix focus to not wind up nowhere.
            }
            metrics.remove(i);
        }
        updatey2range.onEvent(null);

        maybeSetTextbox(qs, "ylabel", ylabel);
        maybeSetTextbox(qs, "y2label", y2label);
        maybeSetTextbox(qs, "yformat", yformat);
        maybeSetTextbox(qs, "y2format", y2format);
        maybeSetTextbox(qs, "yrange", yrange);
        maybeSetTextbox(qs, "y2range", y2range);
        ylog.setValue(qs.containsKey("ylog"));
        y2log.setValue(qs.containsKey("y2log"));

        if (qs.containsKey("key")) {
            final String key = qs.getFirst("key");
            keybox.setValue(key.contains(" box"));
            horizontalkey.setValue(key.contains(" horiz"));
            keypos = key.replaceAll(" (box|horiz\\w*)", "");
            keypos_map.get(keypos).setChecked(true);
        } else {
            keybox.setValue(false);
            horizontalkey.setValue(false);
            keypos_map.get("top right").setChecked(true);
            keypos = "";
        }
        nokey.setValue(qs.containsKey("nokey"));
        smooth.setValue(qs.containsKey("smooth"));
    }

    private void refreshGraph() {
        final Date start = start_datebox.getValue();
        if (start == null) {
            graphstatus.setText("Please specify a start time.");
            return;
        }
        final Date end = end_datebox.getValue();
        if (end != null && !autoreload.getValue()) {
            if (end.getTime() <= start.getTime()) {
                end_datebox.addStyleName("dateBoxFormatError");
                graphstatus.setText("End time must be after start time!");
                return;
            }
        }
        final StringBuilder url = new StringBuilder();
        url.append("/q?start=").append(FULLDATE.format(start));
        if (end != null && !autoreload.getValue()) {
            url.append("&end=").append(FULLDATE.format(end));
        } else {
            // If there's no end-time, the graph may change while the URL remains
            // the same.  No browser seems to re-fetch an image once it's been
            // fetched, even if we destroy the DOM object and re-created it with the
            // same src attribute.  This has nothing to do with caching headers sent
            // by the server.  The browsers simply won't retrieve the same URL again
            // through JavaScript manipulations, period.  So as a workaround, we save
            // a special parameter that the server will delete from the query.
            url.append("&ignore=" + nrequests++);
        }
        if (!addAllMetrics(url)) {
            return;
        }
        addLabels(url);
        addFormats(url);
        addYRanges(url);
        addLogscales(url);
        if (nokey.getValue()) {
            url.append("&nokey");
        } else if (!keypos.isEmpty() || horizontalkey.getValue()) {
            url.append("&key=");
            if (!keypos.isEmpty()) {
                url.append(keypos);
            }
            if (horizontalkey.getValue()) {
                url.append(" horiz");
            }
            if (keybox.getValue()) {
                url.append(" box");
            }
        }
        url.append("&wxh=").append(wxh.getText());
        if (smooth.getValue()) {
            url.append("&smooth=csplines");
        }
        final String unencodedUri = url.toString();
        final String uri = URL.encode(unencodedUri);
        if (uri.equals(lastgraphuri)) {
            return;  // Don't re-request the same graph.
        } else if (pending_requests++ > 0) {
            return;
        }
        lastgraphuri = uri;
        graphstatus.setText("Loading graph...");
        asyncGetJson(uri + "&json", new GotJsonCallback() {
            public void got(final JSONValue json) {
                if (autoreoload_timer != null) {
                    autoreoload_timer.cancel();
                    autoreoload_timer = null;
                }
                final JSONObject result = json.isObject();
                final JSONValue err = result.get("err");
                String msg = "";
                if (err != null) {
                    displayError("An error occurred while generating the graph: "
                            + err.isString().stringValue());
                    graphstatus.setText("Please correct the error above.");
                } else {
                    clearError();

                    String history = unencodedUri.substring(3)      // Remove "/q?".
                            .replaceFirst("ignore=[^&]*&", "");  // Unnecessary cruft.
                    if (autoreload.getValue()) {
                        history += "&autoreload=" + autoreoload_interval.getText();
                    }
                    if (!history.equals(History.getToken())) {
                        History.newItem(history, false);
                    }

                    final JSONValue nplotted = result.get("plotted");
                    final JSONValue cachehit = result.get("cachehit");
                    if (cachehit != null) {
                        msg += "Cache hit (" + cachehit.isString().stringValue() + "). ";
                    }
                    if (nplotted != null && nplotted.isNumber().doubleValue() > 0) {
                        graph.setUrl(uri + "&png");
                        graph.setVisible(true);

                        msg += result.get("points").isNumber() + " points retrieved, "
                                + nplotted + " points plotted";
                    } else {
                        graph.setVisible(false);
                        msg += "Your query didn't return anything";
                    }
                    final JSONValue timing = result.get("timing");
                    if (timing != null) {
                        msg += " in " + timing + "ms.";
                    } else {
                        msg += '.';
                    }
                }
                final JSONValue info = result.get("info");
                if (info != null) {
                    if (!msg.isEmpty()) {
                        msg += ' ';
                    }
                    msg += info.isString().stringValue();
                }
                graphstatus.setText(msg);
                if (result.get("etags") != null) {
                    final JSONArray etags = result.get("etags").isArray();
                    final int netags = etags.size();
                    for (int i = 0; i < netags; i++) {
                        if (i >= metrics.getWidgetCount()) {
                            break;
                        }
                        final Widget widget = metrics.getWidget(i);
                        if (!(widget instanceof MetricForm)) {
                            break;
                        }
                        final MetricForm metric = (MetricForm) widget;
                        final JSONArray tags = etags.get(i).isArray();
                        final int ntags = tags.size();
                        for (int j = 0; j < ntags; j++) {
                            metric.autoSuggestTag(tags.get(j).isString().stringValue());
                        }
                    }
                }
                if (autoreload.getValue()) {
                    final int reload_in = Integer.parseInt(autoreoload_interval.getValue());
                    if (reload_in >= 5) {
                        autoreoload_timer = new Timer() {
                            public void run() {
                                // Verify that we still want auto reload and that the graph
                                // hasn't been updated in the mean time.
                                if (autoreload.getValue() && lastgraphuri == uri) {
                                    // Force refreshGraph to believe that we want a new graph.
                                    lastgraphuri = "";
                                    refreshGraph();
                                }
                            }
                        };
                        autoreoload_timer.schedule(reload_in * 1000);
                    }
                }
                if (--pending_requests > 0) {
                    pending_requests = 0;
                    refreshGraph();
                }
            }
        });
    }

    private boolean addAllMetrics(final StringBuilder url) {
        boolean found_metric = false;
        for (final Widget widget : metrics) {
            if (!(widget instanceof MetricForm)) {
                continue;
            }
            final MetricForm metric = (MetricForm) widget;
            found_metric |= metric.buildQueryString(url);
        }
        if (!found_metric) {
            graphstatus.setText("Please specify a metric.");
        }
        return found_metric;
    }

    private void asyncGetJson(final String url, final GotJsonCallback callback) {
        final RequestBuilder builder = new RequestBuilder(RequestBuilder.GET, url);
        try {
            builder.sendRequest(null, new RequestCallback() {
                public void onError(final Request request, final Throwable e) {
                    displayError("Failed to get " + url + ": " + e.getMessage());
                    // Since we don't call the callback we've been given, reset this
                    // bit of state as we're not going to retry anything right now.
                    pending_requests = 0;
                }

                public void onResponseReceived(final Request request,
                                               final Response response) {
                    final int code = response.getStatusCode();
                    if (code == Response.SC_OK) {
                        clearError();
                        callback.got(JSONParser.parse(response.getText()));
                        return;
                    } else if (code >= Response.SC_BAD_REQUEST) {  // 400+ => Oops.
                        // Since we don't call the callback we've been given, reset this
                        // bit of state as we're not going to retry anything right now.
                        pending_requests = 0;
                        String err = response.getText();
                        // If the response looks like a JSON object, it probably contains
                        // an error message.
                        if (!err.isEmpty() && err.charAt(0) == '{') {
                            final JSONValue json = JSONParser.parse(err);
                            final JSONObject result = json == null ? null : json.isObject();
                            final JSONValue jerr = result == null ? null : result.get("err");
                            final JSONString serr = jerr == null ? null : jerr.isString();
                            err = serr.stringValue();
                            // If the error message has multiple lines (which is common if
                            // it contains a stack trace), show only the first line and
                            // hide the rest in a panel users can expand.
                            final int newline = err.indexOf('\n', 1);
                            final String msg = "Request failed: " + response.getStatusText();
                            if (newline < 0) {
                                displayError(msg + ": " + err);
                            } else {
                                displayError(msg);
                                final DisclosurePanel dp =
                                        new DisclosurePanel(err.substring(0, newline));
                                RootPanel.get("queryuimain").add(dp);  // Attach the widget.
                                final InlineLabel content =
                                        new InlineLabel(err.substring(newline, err.length()));
                                content.addStyleName("fwf");  // For readable stack traces.
                                dp.setContent(content);
                                current_error.getElement().appendChild(dp.getElement());
                            }
                        } else {
                            displayError("Request failed while getting " + url + ": "
                                    + response.getStatusText());
                            // Since we don't call the callback we've been given, reset this
                            // bit of state as we're not going to retry anything right now.
                            pending_requests = 0;
                        }
                        graphstatus.setText("");
                    }
                }
            });
        } catch (RequestException e) {
            displayError("Failed to get " + url + ": " + e.getMessage());
        }
    }

    private void displayError(final String errmsg) {
        current_error.setText(errmsg);
        current_error.setVisible(true);
    }

    private void clearError() {
        current_error.setVisible(false);
    }

    static void setTextAlignCenter(final Element element) {
        element.getStyle().setProperty("textAlign", "center");
    }

    /**
     * Zoom box and associated event handlers.
     */
    private final class ZoomBox extends HTML
            implements MouseUpHandler, MouseMoveHandler, MouseDownHandler {

        /**
         * "Fudge factor" to account for the axes present on the image.
         */
        private static final int OFFSET_WITH_AXIS = 45;
        private static final int OFFSET_WITHOUT_AXIS = 15;

        private boolean zoom_selection_active = false;
        /**
         * Rectangle of the selection.
         */
        private int start_x;
        private int end_x;
        private int start_y;
        private int end_y;

        private HandlerRegistration graph_move_handler;
        private HandlerRegistration box_move_handler;

        ZoomBox() {
            // Set ourselves up as the event handler for all mouse-draggable events.
            graph.addMouseDownHandler(this);
            graph.addMouseUpHandler(this);

            // Also save the handlers on the actual zoom highlight box (this is in
            // case the cursor gets on the zoombox, so that it keeps responding
            // correctly).
            super.addMouseUpHandler(this);

            final Style style = super.getElement().getStyle();
            style.setProperty("background", "red");
            style.setProperty("filter", "alpha(opacity=50)");
            style.setProperty("opacity", "0.4");
            // Needed to make this object focusable.
            super.getElement().setAttribute("tabindex", "-1");
        }

        @Override
        public void onMouseDown(final MouseDownEvent event) {
            event.preventDefault();

            // Check if the zoom selection is active, if so, it's possible that the
            // mouse left the browser mid-selection and got stuck enabled even
            // though the mouse isn't still pressed. If that's the case, do a similar
            // operation to the onMouseUp event.
            if (zoom_selection_active) {
                endSelection(event);
                return;
            }

            final Element image = graph.getElement();
            zoom_selection_active = true;
            start_x = event.getRelativeX(image);
            start_y = event.getRelativeY(image);
            end_x = 0;
            end_y = 0;

            graphbox.setWidgetPosition(this, start_x, start_y);
            super.setWidth("0px");
            super.setHeight("0px");
            super.setVisible(true);
            // Workaround to steal the focus from whatever had it previously,
            // which may cause the graph to reload as a side effect.
            super.getElement().focus();

            graph_move_handler = graph.addMouseMoveHandler(this);
            box_move_handler = super.addMouseMoveHandler(this);
        }

        @Override
        public void onMouseMove(final MouseMoveEvent event) {
            event.preventDefault();

            final int x = event.getRelativeX(graph.getElement());
            final int y = event.getRelativeY(graph.getElement());
            int left;
            int top;
            int width;
            int height;

            // Figure out the top, left, height, and width of the box based
            // on current cursor location.
            if (x < start_x) {
                left = x;
                width = start_x - x;
            } else {
                left = start_x;
                width = x - start_x;
            }
            if (y < start_y) {
                top = y;
                height = start_y - y;
            } else {
                top = start_y;
                height = y - start_y;
            }

            // Resize / move the box as needed based on cursor location.
            super.setVisible(false);
            graphbox.setWidgetPosition(this, left, top);
            super.setWidth(width + "px");
            super.setHeight(height + "px");
            super.setVisible(true);
        }

        @Override
        public void onMouseUp(final MouseUpEvent event) {
            if (zoom_selection_active) {
                endSelection(event);
            }
        }

        /**
         * Perform operations for when a user completes their selection.
         * This involves removing the highlight box and kicking off the
         * zoom in operation.
         *
         * @param event The event that triggered the end of the selection.
         */
        private <H extends EventHandler> void endSelection(final MouseEvent<H> event) {
            zoom_selection_active = false;

            // Stop tracking cursor movements to improve performance.
            graph_move_handler.removeHandler();
            graph_move_handler = null;
            box_move_handler.removeHandler();
            box_move_handler = null;

            final Element image = graph.getElement();
            end_x = event.getRelativeX(image);
            end_y = event.getRelativeY(image);

            // Hide the zoom box
            super.setVisible(false);
            super.setWidth("0px");
            super.setHeight("0px");

            // Calculate the true start/end points of the zoom area selected by
            // mouse. If the mouse was dragged left on the graph before being
            // let up, then start_x is the right-most edge of the zoomable area.
            // If the mouse was dragged right on the graph before being let up,
            // then start_x is the left-most edge of the zoomable area.
            if (start_x < end_x) {
                start_x = start_x - OFFSET_WITH_AXIS;
                end_x = end_x - OFFSET_WITH_AXIS;
            } else {
                final int saved_start = start_x;
                start_x = end_x - OFFSET_WITH_AXIS;
                end_x = saved_start - OFFSET_WITH_AXIS;
            }
            int actual_width = graph.getWidth() - OFFSET_WITH_AXIS;
            if (y2range.isEnabled()) {  // If we have a second Y axis.
                actual_width -= OFFSET_WITH_AXIS;
            } else {
                actual_width -= OFFSET_WITHOUT_AXIS;
            }

            // Prevent division by zero if image is pathologically small.
            // or: Prevent changing anything if the distance the cursor traveled was
            // too small (as happens during a simple click or unintentional click).
            if (actual_width < 1 || end_x - start_x <= 5) {
                return;
            }

            // Total span of time represented between the start and end times.
            final long duration;
            final long start = start_datebox.getValue().getTime();
            {
                final long end;
                final Date end_date = end_datebox.getValue();
                if (end_date != null) {
                    end = end_date.getTime();
                } else {
                    end = new Date().getTime();
                }
                duration = end - start;
            }

            // Get the start and end positions of the mouse drag operation on the
            // image as a percentage of the image size.
            final long start_change = start_x * duration / actual_width;
            final long end_change = end_x * duration / actual_width;

            start_datebox.setValue(new Date(start + start_change));
            end_datebox.setValue(new Date(start + end_change));
            refreshGraph();
        }

    }

    ;

    private final class AdjustYRangeCheckOnClick implements ClickHandler {

        private final CheckBox box;
        private final ValidatedTextBox range;

        public AdjustYRangeCheckOnClick(final CheckBox box,
                                        final ValidatedTextBox range) {
            this.box = box;
            this.range = range;
        }

        public void onClick(final ClickEvent event) {
            if (box.isEnabled() && box.getValue()
                    && "[0:]".equals(range.getValue())) {
                range.setValue("[1:]");
            } else if (box.isEnabled() && !box.getValue()
                    && "[1:]".equals(range.getValue())) {
                range.setValue("[0:]");
            }
        }

    }

    ;

    /**
     * Ensures all the widgets in the given panel have the same size.
     * Otherwise by default the panel will automatically resize itself to the
     * contents of the currently active panel's widget, which is annoying
     * because it makes a number of things move around in the UI.
     *
     * @param panel The panel containing the widgets to resize.
     */
    private static void ensureSameWidgetSize(final DecoratedTabPanel panel) {
        if (!panel.isAttached()) {
            throw new IllegalArgumentException("panel not attached: " + panel);
        }
        int maxw = 0;
        int maxh = 0;
        for (final Widget widget : panel) {
            final int w = widget.getOffsetWidth();
            final int h = widget.getOffsetHeight();
            if (w > maxw) {
                maxw = w;
            }
            if (h > maxh) {
                maxh = h;
            }
        }
        if (maxw == 0 || maxh == 0) {
            throw new IllegalArgumentException("maxw=" + maxw + " maxh=" + maxh);
        }
        for (final Widget widget : panel) {
            setOffsetWidth(widget, maxw);
            setOffsetHeight(widget, maxh);
        }
    }

    /**
     * Properly sets the total width of a widget.
     * This takes into account decorations such as border, margin, and padding.
     */
    private static void setOffsetWidth(final Widget widget, int width) {
        widget.setWidth(width + "px");
        final int offset = widget.getOffsetWidth();
        if (offset > 0) {
            width -= offset - width;
            if (width > 0) {
                widget.setWidth(width + "px");
            }
        }
    }

    /**
     * Properly sets the total height of a widget.
     * This takes into account decorations such as border, margin, and padding.
     */
    private static void setOffsetHeight(final Widget widget, int height) {
        widget.setHeight(height + "px");
        final int offset = widget.getOffsetHeight();
        if (offset > 0) {
            height -= offset - height;
            if (height > 0) {
                widget.setHeight(height + "px");
            }
        }
    }

}
